/* * Copyright (c) 2014. Escalon System-Entwicklung, Dietrich Schulten * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for * the specific language governing permissions and limitations under the License. */ package de.escalon.hypermedia.spring.xhtml; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import de.escalon.hypermedia.PropertyUtils; import de.escalon.hypermedia.affordance.DataType; import de.escalon.hypermedia.spring.DefaultDocumentationProvider; import de.escalon.hypermedia.spring.DocumentationProvider; import org.springframework.hateoas.Resource; import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.Resources; import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; import org.springframework.http.converter.AbstractHttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.util.*; import javax.servlet.http.HttpServletRequest; import java.beans.BeanInfo; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.io.*; import java.lang.annotation.Annotation; import java.lang.reflect.*; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.Charset; import java.util.*; import java.util.Map.Entry; /** * Message converter which represents a restful API as xhtml which can be used by the browser or a rest client. Converts * java beans and spring-hateoas Resources to xhtml and maps the body of x-www-form-urlencoded requests to RequestBody * method parameters. The media-type xhtml does not officially support methods other than GET or POST, therefore we must * "tunnel" other methods when this converter is used with the browser. Spring's {@link * org.springframework.web.filter.HiddenHttpMethodFilter} allows to do that with relative ease. * * @author Dietrich Schulten */ public class XhtmlResourceMessageConverter extends AbstractHttpMessageConverter<Object> { private Charset charset = Charset.forName("UTF-8"); private String methodParam = "_method"; private List<String> stylesheets = Collections.emptyList(); private DocumentationProvider documentationProvider = new DefaultDocumentationProvider(); public XhtmlResourceMessageConverter() { this.setSupportedMediaTypes(Arrays.asList(MediaType.TEXT_HTML, MediaType.APPLICATION_FORM_URLENCODED)); } @Override protected boolean supports(Class<?> clazz) { return true; } public Object read(java.lang.reflect.Type type, Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { final Class clazz; if (type instanceof Class) { clazz = (Class) type; } else if (type instanceof ParameterizedType) { ParameterizedType parameterizedType = (ParameterizedType) type; Type rawType = parameterizedType.getRawType(); if (rawType instanceof Class) { clazz = (Class) rawType; } else { throw new IllegalArgumentException("unexpected raw type " + rawType); } } else { throw new IllegalArgumentException("unexpected type " + type); } return readInternal(clazz, inputMessage); } @Override protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { InputStream is; if (inputMessage instanceof ServletServerHttpRequest) { // this is necessary to support HiddenHttpMethodFilter // thanks to https://www.w3.org/html/wg/tracker/issues/195 // but see http://dev.w3.org/html5/decision-policy/html5-2014-plan.html#issues // and http://cameronjones.github.io/form-http-extensions/index.html // and http://www.w3.org/TR/form-http-extensions/ // TODO recognize this more safely or make the filter mandatory MediaType contentType = inputMessage.getHeaders() .getContentType(); Charset charset = contentType.getCharSet() != null ? contentType.getCharSet() : this.charset; ServletServerHttpRequest servletServerHttpRequest = (ServletServerHttpRequest) inputMessage; HttpServletRequest servletRequest = servletServerHttpRequest.getServletRequest(); is = getBodyFromServletRequestParameters(servletRequest, charset.displayName(Locale.US)); } else { is = inputMessage.getBody(); } return readRequestBody(clazz, is, charset); } /** * From {@link ServletServerHttpRequest}: Use {@link javax.servlet.ServletRequest#getParameterMap()} to reconstruct * the body of a form 'POST' providing a predictable outcome as opposed to reading from the body, which can fail if * any other code has used ServletRequest to access a parameter thus causing the input stream to be "consumed". */ private InputStream getBodyFromServletRequestParameters(HttpServletRequest request, String charset) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(1024); Writer writer = new OutputStreamWriter(bos, charset); @SuppressWarnings("unchecked") Map<String, String[]> form = request.getParameterMap(); for (Iterator<String> nameIterator = form.keySet() .iterator(); nameIterator.hasNext(); ) { String name = nameIterator.next(); List<String> values = Arrays.asList(form.get(name)); for (Iterator<String> valueIterator = values.iterator(); valueIterator.hasNext(); ) { String value = valueIterator.next(); writer.write(URLEncoder.encode(name, charset)); if (value != null) { writer.write('='); writer.write(URLEncoder.encode(value, charset)); if (valueIterator.hasNext()) { writer.write('&'); } } } if (nameIterator.hasNext()) { writer.append('&'); } } writer.flush(); return new ByteArrayInputStream(bos.toByteArray()); } private Object readRequestBody(Class<?> clazz, InputStream inputStream, Charset charset) throws IOException { String body = StreamUtils.copyToString(inputStream, charset); String[] pairs = StringUtils.tokenizeToStringArray(body, "&"); MultiValueMap<String, String> formValues = new LinkedMultiValueMap<String, String>(pairs.length); for (String pair : pairs) { int idx = pair.indexOf('='); if (idx == -1) { formValues.add(URLDecoder.decode(pair, charset.name()), null); } else { String name = URLDecoder.decode(pair.substring(0, idx), charset.name()); String value = URLDecoder.decode(pair.substring(idx + 1), charset.name()); formValues.add(name, value); } } return recursivelyCreateObject(clazz, formValues, ""); } Object recursivelyCreateObject(Class<?> clazz, MultiValueMap<String, String> formValues, String parentParamName) { if (Map.class.isAssignableFrom(clazz)) { throw new IllegalArgumentException("Map not supported"); } else if (Collection.class.isAssignableFrom(clazz)) { throw new IllegalArgumentException("Collection not supported"); } else { try { Constructor[] constructors = clazz.getConstructors(); Constructor constructor = PropertyUtils.findDefaultCtor(constructors); if (constructor == null) { constructor = PropertyUtils.findJsonCreator(constructors, JsonCreator.class); } Assert.notNull(constructor, "no default constructor or JsonCreator found"); int parameterCount = constructor.getParameterTypes().length; Object[] args = new Object[parameterCount]; if (parameterCount > 0) { Annotation[][] annotationsOnParameters = constructor.getParameterAnnotations(); Class[] parameters = constructor.getParameterTypes(); int paramIndex = 0; for (Annotation[] annotationsOnParameter : annotationsOnParameters) { for (Annotation annotation : annotationsOnParameter) { if (JsonProperty.class == annotation.annotationType()) { JsonProperty jsonProperty = (JsonProperty) annotation; String paramName = jsonProperty.value(); List<String> formValue = formValues.get(parentParamName + paramName); Class<?> parameterType = parameters[paramIndex]; if (DataType.isSingleValueType(parameterType)) { if (formValue != null) { if (formValue.size() == 1) { args[paramIndex++] = DataType.asType(parameterType, formValue.get(0)); } else { // // TODO create proper collection type throw new IllegalArgumentException("variable list not supported"); // List<Object> listValue = new ArrayList<Object>(); // for (String item : formValue) { // listValue.add(DataType.asType(parameterType, formValue.get(0))); // } // args[paramIndex++] = listValue; } } else { args[paramIndex++] = null; } } else { args[paramIndex++] = recursivelyCreateObject(parameterType, formValues, parentParamName + paramName + "."); } } } } Assert.isTrue(args.length == paramIndex, "not all constructor arguments of @JsonCreator are " + "annotated with @JsonProperty"); } Object ret = constructor.newInstance(args); BeanInfo beanInfo = Introspector.getBeanInfo(clazz); PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors(); for (PropertyDescriptor propertyDescriptor : propertyDescriptors) { Method writeMethod = propertyDescriptor.getWriteMethod(); String name = propertyDescriptor.getName(); List<String> strings = formValues.get(name); if (writeMethod != null && strings != null && strings.size() == 1) { writeMethod.invoke(ret, DataType.asType(propertyDescriptor.getPropertyType(), strings.get(0)) ); // TODO lists, consume values from ctor } } return ret; } catch (Exception e) { throw new RuntimeException("Failed to instantiate bean " + clazz.getName(), e); } } } @Override protected void writeInternal(Object t, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { XhtmlWriter xhtmlWriter = new XhtmlWriter(new OutputStreamWriter(outputMessage.getBody(), "UTF-8")); xhtmlWriter.setMethodParam(methodParam); xhtmlWriter.setStylesheets(stylesheets); xhtmlWriter.setDocumentationProvider(documentationProvider); xhtmlWriter.beginHtml("Form"); writeNewResource(xhtmlWriter, t); xhtmlWriter.endHtml(); xhtmlWriter.flush(); } static final Set<String> FILTER_RESOURCE_SUPPORT = new HashSet<String>(Arrays.asList("class", "links", "id")); private void writeNewResource(XhtmlWriter writer, Object object) throws IOException { writer.beginUnorderedList(); writeResource(writer, object); writer.endUnorderedList(); } /** * Recursively converts object to xhtml data. * * @param object * to convert * @param writer * to write to */ private void writeResource(XhtmlWriter writer, Object object) { if (object == null) { return; } try { if (object instanceof Resource) { Resource<?> resource = (Resource<?>) object; writer.beginListItem(); writeResource(writer, resource.getContent()); writer.writeLinks(resource.getLinks()); writer.endListItem(); } else if (object instanceof Resources) { Resources<?> resources = (Resources<?>) object; // TODO set name using EVO see HypermediaSupportBeanDefinitionRegistrar writer.beginListItem(); writer.beginUnorderedList(); Collection<?> content = resources.getContent(); writeResource(writer, content); writer.endUnorderedList(); writer.writeLinks(resources.getLinks()); writer.endListItem(); } else if (object instanceof ResourceSupport) { ResourceSupport resource = (ResourceSupport) object; writer.beginListItem(); writeObject(writer, resource); writer.writeLinks(resource.getLinks()); writer.endListItem(); } else if (object instanceof Collection) { Collection<?> collection = (Collection<?>) object; for (Object item : collection) { writeResource(writer, item); } } else { // TODO: write li for simple objects in Resources Collection writeObject(writer, object); } } catch (Exception ex) { throw new RuntimeException("failed to transform object " + object, ex); } } private void beginListGroupWithItem(XhtmlWriter writer) throws IOException { writer.beginUnorderedList(); writer.beginListItem(); } private void endListGroupWithItem(XhtmlWriter writer) throws IOException { writer.endListItem(); writer.endUnorderedList(); } private void writeObject(XhtmlWriter writer, Object object) throws IOException, IllegalAccessException, InvocationTargetException { if (!DataType.isSingleValueType(object.getClass())) { writer.beginDl(); } if (object instanceof Map) { Map<?, ?> map = (Map<?, ?>) object; for (Entry<?, ?> entry : map.entrySet()) { String name = entry.getKey() .toString(); Object content = entry.getValue(); String docUrl = documentationProvider.getDocumentationUrl(name, content); writeObjectAttributeRecursively(writer, name, content, docUrl); } } else if (object instanceof Enum) { String name = ((Enum) object).name(); String docUrl = documentationProvider.getDocumentationUrl(name, object); writeDdForScalarValue(writer, object); } else if (object instanceof Currency) { // TODO configurable classes which should be rendered with toString // or use JsonSerializer or DataType? String name = object.toString(); String docUrl = documentationProvider.getDocumentationUrl(name, object); writeDdForScalarValue(writer, object); } else { Class<?> aClass = object.getClass(); Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors(object); // getFields retrieves public only Field[] fields = aClass.getFields(); for (Field field : fields) { String name = field.getName(); if (!propertyDescriptors.containsKey(name)) { Object content = field.get(object); String docUrl = documentationProvider.getDocumentationUrl(field, content); //<a href="http://schema.org/review">http://schema.org/performer</a> writeObjectAttributeRecursively(writer, name, content, docUrl); } } for (PropertyDescriptor propertyDescriptor : propertyDescriptors.values()) { String name = propertyDescriptor.getName(); if (FILTER_RESOURCE_SUPPORT.contains(name)) { continue; } Method readMethod = propertyDescriptor.getReadMethod(); if (readMethod != null) { Object content = readMethod.invoke(object); String docUrl = documentationProvider.getDocumentationUrl(readMethod, content); writeObjectAttributeRecursively(writer, name, content, docUrl); } } } if (!DataType.isSingleValueType(object.getClass())) { writer.endDl(); } } private void writeObjectAttributeRecursively(XhtmlWriter writer, String name, Object content, String documentationUrl) throws IOException { Object value = getContentAsScalarValue(content); if (!contentIsEmpty(content)) { writeDtWithDoc(writer, name, documentationUrl); } if (value != null) { if (value != NULL_VALUE) { writeDdForScalarValue(writer, value); } } else if (DataType.isSingleValueType(content.getClass())) { writeDdForScalarValue(writer, content.toString()); } else { writer.beginDd(); writeNewResource(writer, content); writer.endDd(); } } private boolean contentIsEmpty(Object content) { final boolean ret; if (content != null) { if (content instanceof Collection) { ret = ((Collection) content).isEmpty(); } else if (content instanceof Map) { ret = ((Map) content).isEmpty(); } else if (content instanceof String) { ret = ((String) content).isEmpty(); } else { ret = false; } } else { ret = true; } return ret; } private void writeDtWithDoc(XhtmlWriter writer, String name, String documentationUrl) throws IOException { if (documentationUrl == null) { writer.beginDt(); writer.write(name); writer.endDt(); } else { writer.beginDt(); writer.beginAnchor(XhtmlWriter.OptionalAttributes.attr("href", documentationUrl) .and("title", documentationUrl)); writer.write(name); writer.endAnchor(); writer.endDt(); } } private void writeDdForScalarValue(XhtmlWriter writer, Object value) throws IOException { writer.beginDd(); writer.write(value.toString()); writer.endDd(); } /** * Sets method param name for HTML PUT/DELETE/PATCH workaround. * * @param methodParam * to use * @see org.springframework.web.filter.HiddenHttpMethodFilter */ public void setMethodParam(String methodParam) { this.methodParam = methodParam; } /** * Sets css stylesheets to apply to the form. * * @param stylesheets * urls of css stylesheets to include, e.g. "https://maxcdn.bootstrapcdn.com/bootstrap/3.3 * .4/css/bootstrap.min.css" */ public void setStylesheets(List<String> stylesheets) { Assert.notNull(stylesheets); this.stylesheets = stylesheets; } public void setDocumentationProvider(DocumentationProvider documentationProvider) { this.documentationProvider = documentationProvider; } static class NullValue { } public static final NullValue NULL_VALUE = new NullValue(); private static Object getContentAsScalarValue(Object content) { Object value = null; if (content == null) { value = NULL_VALUE; } else if (content instanceof String || content instanceof Number || content.equals(false) || content.equals (true)) { value = content; } return value; } }